Coverage Report

Created: 2026-06-19 16:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\src\client\mod.rs
Line
Count
Source
1
//! Client implementation
2
3
#![deny(clippy::implicit_return)]
4
#![allow(clippy::needless_return, clippy::doc_overindented_list_items)]
5
#![warn(missing_docs)]
6
7
use log::{error, info, warn};
8
use std::fs::File;
9
use std::io::{self, BufReader};
10
use std::path::Path;
11
use std::time::Duration;
12
use windows::Win32::UI::Input::KeyboardAndMouse::VK_C;
13
14
use crate::utils::config::ClientConfig;
15
use crate::utils::windows::{get_console_title, set_console_color, WindowsApi};
16
use ssh2_config::{ParseRule, SshConfig};
17
use tokio::net::windows::named_pipe::NamedPipeClient;
18
use tokio::process::{Child, Command};
19
use tokio::sync::watch;
20
use tokio::{io::Interest, net::windows::named_pipe::ClientOptions};
21
use windows::Win32::System::Console::{
22
    CONSOLE_CHARACTER_ATTRIBUTES, INPUT_RECORD, INPUT_RECORD_0, KEY_EVENT, KEY_EVENT_RECORD,
23
    LEFT_ALT_PRESSED, RIGHT_ALT_PRESSED, SHIFT_PRESSED,
24
};
25
26
use crate::{
27
    protocol::{
28
        deserialization::parse_daemon_to_client_messages, serialization::serialize_pid,
29
        ClientState, DaemonToClientMessage, SERIALIZED_INPUT_RECORD_0_LENGTH,
30
        SERIALIZED_PID_LENGTH,
31
    },
32
    utils::constants::{PIPE_NAME, PKG_NAME},
33
};
34
35
/// Possible results when reading from the named pipe and writing to the
36
/// current process's stdinput.
37
enum ReadWriteResult {
38
    /// We wrote all complete [INPUT_RECORD_0] sequences we read from
39
    /// the named pipe to stdin.
40
    Success {
41
        /// Incomplete [INPUT_RECORD_0] sequence.
42
        ///
43
        /// What we read from the named pipe is a serialized [INPUT_RECORD_0].`KeyEvent`.
44
        /// As this is simply a [`SERIALIZED_INPUT_RECORD_0_LENGTH`] byte long sequence and we try to read from the pipe until we
45
        /// have some of the data it can happen that during any one read/write iteration we don't
46
        /// read the full sequence so we must keep track of what we read for next iterations
47
        /// where we will be able to read the remainder of the sequence.
48
        remainder: Vec<u8>,
49
        /// List of [KEY_EVENT_RECORD]s we have read from the named pipe.
50
        ///
51
        /// Used to detect the `Alt + Shift + C` key combination used
52
        /// to close the console window after the client process encountered an unexpected error.
53
        key_event_records: Vec<KEY_EVENT_RECORD>,
54
    },
55
    /// Trying to read from the pipe would require us to wait for data.
56
    WouldBlock,
57
    /// Something went wrong.
58
    Err,
59
    /// The pipe was closed.
60
    Disconnect,
61
}
62
63
/// Duration of the action-feedback flash painted on a highlighted client
64
/// when the user toggles the state.
65
const HIGHLIGHT_FLASH_DURATION: Duration = Duration::from_millis(250);
66
67
/// Resolve the console color for a `(state, highlighted)` combination;
68
/// highlight overlays the disabled color.
69
///
70
/// # Arguments
71
///
72
/// * `state`                     - The client's current [`ClientState`].
73
/// * `highlighted`               - `true` while the client is the selected
74
///                                 window in the daemon's enable/disable
75
///                                 submenu.
76
/// * `original_console_color`    - Console color captured at startup.
77
/// * `disabled_console_color`    - Color applied while the client is
78
///                                 [`ClientState::Disabled`].
79
/// * `highlighted_console_color` - Color applied while the client is
80
///                                 highlighted.
81
///
82
/// # Returns
83
///
84
/// The color to paint, or `None` when `original_console_color` is `None`.
85
9
fn get_effective_color(
86
9
    state: ClientState,
87
9
    highlighted: bool,
88
9
    original_console_color: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
89
9
    disabled_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
90
9
    highlighted_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
91
9
) -> Option<CONSOLE_CHARACTER_ATTRIBUTES> {
92
9
    let 
original6
= original_console_color
?3
;
93
6
    if highlighted {
94
4
        return Some(highlighted_console_color);
95
2
    }
96
2
    match state {
97
1
        ClientState::Active => return Some(original),
98
1
        ClientState::Disabled => return Some(disabled_console_color),
99
    }
100
9
}
101
102
/// Resolve the underlying state color, with the highlight overlay bypassed,
103
/// for the action-feedback flash.
104
///
105
/// # Arguments
106
///
107
/// * `state`                    - The just-applied [`ClientState`].
108
/// * `original_console_color`   - Console color captured at startup.
109
/// * `disabled_console_color`   - Color applied while the client is
110
///                                [`ClientState::Disabled`].
111
///
112
/// # Returns
113
///
114
/// The color to paint, or `None` when `original_console_color` is `None`.
115
5
fn get_flash_color(
116
5
    state: ClientState,
117
5
    original_console_color: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
118
5
    disabled_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
119
5
) -> Option<CONSOLE_CHARACTER_ATTRIBUTES> {
120
5
    let 
original3
= original_console_color
?2
;
121
3
    match state {
122
2
        ClientState::Active => return Some(original),
123
1
        ClientState::Disabled => return Some(disabled_console_color),
124
    }
125
5
}
126
127
/// Bundle of the three colors [`run_visuals_loop`] chooses between
128
/// when repainting the per-client console.
129
struct ConsolePalette {
130
    /// Color captured before the SSH child wrote anything; `None`
131
    /// degrades every paint to a no-op.
132
    original: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
133
    /// Color applied while [`ClientState::Disabled`].
134
    disabled: CONSOLE_CHARACTER_ATTRIBUTES,
135
    /// Color applied while the client is the highlighted submenu target.
136
    highlighted: CONSOLE_CHARACTER_ATTRIBUTES,
137
}
138
139
/// Repaint the console to the steady-state color for `(state, highlighted)`.
140
///
141
/// # Arguments
142
///
143
/// * `api`         - The Windows API implementation to use.
144
/// * `state`       - The client's current [`ClientState`].
145
/// * `highlighted` - Whether the client is the submenu's highlighted target.
146
/// * `palette`     - The available colors.
147
/// * `last`        - Most recently painted color; updated in place.
148
2
fn paint_steady(
149
2
    api: &dyn WindowsApi,
150
2
    state: ClientState,
151
2
    highlighted: bool,
152
2
    palette: &ConsolePalette,
153
2
    last: &mut Option<CONSOLE_CHARACTER_ATTRIBUTES>,
154
2
) {
155
2
    paint_console_color(
156
2
        api,
157
2
        get_effective_color(
158
2
            state,
159
2
            highlighted,
160
2
            palette.original,
161
2
            palette.disabled,
162
2
            palette.highlighted,
163
        ),
164
2
        last,
165
    );
166
2
}
167
168
/// Paint the action-feedback flash and return the deadline at which
169
/// the steady-state should be restored.
170
///
171
/// # Arguments
172
///
173
/// * `api`     - The Windows API implementation to use.
174
/// * `state`   - The just-applied [`ClientState`].
175
/// * `palette` - The available colors.
176
/// * `last`    - Most recently painted color; updated in place.
177
///
178
/// # Returns
179
///
180
/// The [`tokio::time::Instant`] the flash should be cleared at.
181
1
fn start_flash(
182
1
    api: &dyn WindowsApi,
183
1
    state: ClientState,
184
1
    palette: &ConsolePalette,
185
1
    last: &mut Option<CONSOLE_CHARACTER_ATTRIBUTES>,
186
1
) -> tokio::time::Instant {
187
1
    paint_console_color(
188
1
        api,
189
1
        get_flash_color(state, palette.original, palette.disabled),
190
1
        last,
191
    );
192
1
    return tokio::time::Instant::now() + HIGHLIGHT_FLASH_DURATION;
193
1
}
194
195
/// Paint `target` if it differs from `last`, then update `last`.
196
///
197
/// Skipping unchanged repaints avoids an unnecessary LPC roundtrip to
198
/// conhost and the post-fill `InvalidateRect`/WM_PAINT.
199
///
200
/// # Arguments
201
///
202
/// * `api`    - The Windows API implementation to use.
203
/// * `target` - The color to paint, or `None` to skip.
204
/// * `last`   - The most recently painted color; updated in-place after a
205
///              successful repaint.
206
7
fn paint_console_color(
207
7
    api: &dyn WindowsApi,
208
7
    target: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
209
7
    last: &mut Option<CONSOLE_CHARACTER_ATTRIBUTES>,
210
7
) {
211
7
    let Some(
color6
) = target else {
212
1
        return;
213
    };
214
6
    if last.map(|c| return 
c.04
) == Some(color.0) {
215
1
        return;
216
5
    }
217
5
    set_console_color(api, color);
218
5
    *last = Some(color);
219
7
}
220
221
/// Write the given [INPUT_RECORD_0] to the console input buffer using the provided API.
222
///
223
/// # Arguments
224
///
225
/// * `api` - The Windows API implementation to use.
226
/// * `input_record` - The [INPUT_RECORD_0].`KeyEvent` input record to write.
227
4
fn write_console_input(api: &dyn WindowsApi, input_record: INPUT_RECORD_0) {
228
4
    let buffer: [INPUT_RECORD; 1] = [INPUT_RECORD {
229
4
        EventType: KEY_EVENT as u16,
230
4
        Event: input_record,
231
4
    }];
232
4
    let mut nb_of_events_written = 0u32;
233
4
    match api.write_console_input(&buffer, &mut nb_of_events_written) {
234
        Ok(_) => {
235
3
            if nb_of_events_written == 0 {
236
1
                error!("Failed to write console input");
237
1
                error!("{:?}", 
api0
.
get_last_error0
());
238
2
            }
239
        }
240
        Err(_) => {
241
1
            error!("Failed to write console input");
242
1
            error!("{:?}", 
api0
.
get_last_error0
());
243
        }
244
    };
245
4
}
246
247
/// Resolve the username from the provided value or SSH config.
248
///
249
/// # Arguments
250
///
251
/// * `username` - Optional username to use. If None, will try to resolve from SSH config.
252
/// * `host` - The hostname (without port) to connect to.
253
/// * `config` - The client configuration containing SSH config path.
254
///
255
/// # Returns
256
///
257
/// The resolved username.
258
12
fn resolve_username(username: Option<String>, host: &str, config: &ClientConfig) -> String {
259
12
    if let Some(
val8
) = username {
260
8
        return val;
261
4
    }
262
263
4
    let mut ssh_config = SshConfig::default();
264
4
    let ssh_config_path = Path::new(config.ssh_config_path.as_str());
265
4
    if ssh_config_path.exists() {
266
2
        let mut reader = BufReader::new(
267
2
            File::open(ssh_config_path).expect("Could not open SSH configuration file."),
268
2
        );
269
2
        ssh_config = SshConfig::default()
270
2
            .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
271
2
            .expect("Failed to parse SSH configuration file");
272
2
    }
273
4
    return ssh_config
274
4
        .query(<&str>::clone(&host))
275
4
        .user
276
4
        .unwrap_or_default();
277
12
}
278
279
/// Build the SSH arguments from the username, host, port, and config.
280
///
281
/// # Arguments
282
///
283
/// * `username`    - The username to connect with.
284
/// * `host`        - The hostname to connect to.
285
/// * `port`        - Optional port number (0-65535).
286
/// * `config`      - The client config indicating how to call the SSH program.
287
///
288
/// # Returns
289
///
290
/// A vector of arguments ready to be passed to the SSH command.
291
12
fn build_ssh_arguments(
292
12
    username: &str,
293
12
    host: &str,
294
12
    port: Option<u16>,
295
12
    config: &ClientConfig,
296
12
) -> Vec<String> {
297
12
    let username_host = format!("{username}@{host}");
298
299
12
    let mut arguments = replace_argument_placeholders(
300
12
        &config.arguments,
301
12
        &config.username_host_placeholder,
302
12
        &username_host,
303
    );
304
305
    // Add port arguments if port was specified
306
12
    if let Some(
port9
) = port {
307
9
        arguments.push("-p".to_string());
308
9
        arguments.push(port.to_string());
309
9
    
}3
310
311
12
    return arguments;
312
12
}
313
314
/// Launch the SSH process.
315
///
316
/// The process might overwrite the console title once it launched, so we wait for that
317
/// to happen and set the title again.
318
///
319
/// # Arguments
320
///
321
/// * `username`    - The username to connect with.
322
/// * `host`        - The hostname to connect to.
323
/// * `port`        - Optional port number (0-65535).
324
/// * `config`      - The client config indicating how to call the SSH program.
325
///
326
/// # Returns
327
///
328
/// The handle to created [Child] process.
329
0
async fn launch_ssh_process(
330
0
    username: &str,
331
0
    host: &str,
332
0
    port: Option<u16>,
333
0
    config: &ClientConfig,
334
0
) -> Child {
335
0
    let arguments = build_ssh_arguments(username, host, port, config);
336
0
    let child = Command::new(&config.program)
337
0
        .args(arguments.clone())
338
0
        .spawn()
339
0
        .unwrap_or_else(|err| {
340
0
            let args: String = arguments.join(" ");
341
0
            error!("{}", err);
342
0
            panic!(
343
                "Failed to launch process `{}` with arguments `{}`",
344
                config.program, args
345
            )
346
        });
347
0
    return child;
348
0
}
349
350
/// Read all available daemon-to-client messages from the named pipe and apply them.
351
///
352
/// Input records are written to the console input buffer using the provided API
353
/// and their key-event payloads are returned via `ReadWriteResult::Success` so
354
/// the caller can detect the Alt+Shift+C close combination. State-change frames
355
/// are forwarded via [`watch::Sender::send_replace`] on `state_sender`, making the
356
/// authoritative [`ClientState`] visible to every watch subscriber (currently
357
/// the visuals task in [`main`]) without coupling this loop to any
358
/// state-dependent rendering. Keep-alive frames are ignored. Partial trailing
359
/// frames are returned as `remainder` for the next call to prepend.
360
///
361
/// # Arguments
362
///
363
/// * `api`                 - The Windows API implementation to use.
364
/// * `named_pipe_client`   - The [Windows named pipe][1] client that has successfully connected to
365
///                           the named pipe created by the daemon.
366
/// * `internal_buffer`     - Vector containing the unconsumed bytes (possibly an
367
///                           incomplete trailing frame) from a previous call.
368
/// * `state_sender`            - Watch sender used to broadcast every
369
///                           [`DaemonToClientMessage::StateChange`] payload as
370
///                           the client's authoritative [`ClientState`].
371
/// * `highlight_sender`        - Watch sender used to broadcast every
372
///                           [`DaemonToClientMessage::Highlight`] payload as
373
///                           the client's current highlight flag.
374
/// # Returns
375
///
376
/// A `ReadWriteResult` indicating whether we were able to read from the named pipe and write the available INPUT_RECORDs
377
/// to the console input buffer or not.
378
///
379
/// [1]: https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes
380
3
async fn read_write_loop(
381
3
    api: &dyn WindowsApi,
382
3
    named_pipe_client: &NamedPipeClient,
383
3
    internal_buffer: &mut Vec<u8>,
384
3
    state_sender: &watch::Sender<ClientState>,
385
3
    highlight_sender: &watch::Sender<bool>,
386
3
) -> ReadWriteResult {
387
3
    let mut buf: [u8; SERIALIZED_INPUT_RECORD_0_LENGTH * 10] =
388
3
        [0; SERIALIZED_INPUT_RECORD_0_LENGTH * 10];
389
3
    match named_pipe_client.try_read(&mut buf) {
390
        Ok(0) => {
391
            // Seems to only happen if the pipe is closed/server disconnects
392
            // indicating that the daemon has been closed.
393
            // Exit the client too in that case.
394
0
            return ReadWriteResult::Disconnect;
395
        }
396
3
        Ok(n) => {
397
3
            internal_buffer.extend_from_slice(&buf[..n]);
398
3
            let (messages, remainder) = parse_daemon_to_client_messages(internal_buffer);
399
3
            let mut key_event_records: Vec<KEY_EVENT_RECORD> = Vec::new();
400
4
            for message in 
messages3
{
401
4
                match message {
402
1
                    DaemonToClientMessage::InputRecord(input_record) => {
403
1
                        write_console_input(api, input_record);
404
1
                        key_event_records.push(unsafe { input_record.KeyEvent });
405
1
                    }
406
2
                    DaemonToClientMessage::StateChange(state) => {
407
2
                        state_sender.send_replace(state);
408
2
                    }
409
1
                    DaemonToClientMessage::Highlight(highlighted) => {
410
1
                        highlight_sender.send_replace(highlighted);
411
1
                    }
412
0
                    DaemonToClientMessage::KeepAlive => {}
413
                }
414
            }
415
3
            return ReadWriteResult::Success {
416
3
                remainder,
417
3
                key_event_records,
418
3
            };
419
        }
420
0
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
421
0
            return ReadWriteResult::WouldBlock;
422
        }
423
0
        Err(e) => {
424
0
            error!("{}", e);
425
0
            return ReadWriteResult::Err;
426
        }
427
    }
428
3
}
429
430
/// Checks if a key event represents the Alt+Shift+C combination.
431
///
432
/// # Arguments
433
///
434
/// * `key_event` - The key event record to check.
435
///
436
/// # Returns
437
///
438
/// `true` if the key event represents Alt+Shift+C, `false` otherwise.
439
8
fn is_alt_shift_c_combination(key_event: &KEY_EVENT_RECORD) -> bool {
440
8
    return (key_event.dwControlKeyState & LEFT_ALT_PRESSED >= 1
441
3
        || key_event.dwControlKeyState & RIGHT_ALT_PRESSED == 1)
442
6
        && key_event.dwControlKeyState & SHIFT_PRESSED >= 1
443
5
        && key_event.wVirtualKeyCode == VK_C.0;
444
8
}
445
446
/// Replaces placeholders in SSH command arguments.
447
///
448
/// # Arguments
449
///
450
/// * `arguments` - The argument templates.
451
/// * `placeholder` - The placeholder string to replace.
452
/// * `replacement` - The value to replace the placeholder with.
453
///
454
/// # Returns
455
///
456
/// A vector of arguments with placeholders replaced.
457
12
fn replace_argument_placeholders(
458
12
    arguments: &[String],
459
12
    placeholder: &str,
460
12
    replacement: &str,
461
12
) -> Vec<String> {
462
12
    return arguments
463
12
        .iter()
464
30
        .
map12
(|arg| return arg.replace(placeholder, replacement))
465
12
        .collect();
466
12
}
467
468
/// Send this process's id over the pipe to the daemon as a 4 byte
469
/// little-endian sequence.
470
///
471
/// The daemon uses the PID to match the pipe connection to the correct
472
/// [`crate::daemon`] `Client` entry. Without this handshake the daemon will
473
/// not forward any input records.
474
///
475
/// # Arguments
476
///
477
/// * `named_pipe_client` - The connected pipe client to write the PID to.
478
///
479
/// # Panics
480
///
481
/// Panics if the pipe write fails in a way that cannot be retried.
482
1
async fn send_pid_handshake(named_pipe_client: &NamedPipeClient) {
483
1
    let pid_bytes = serialize_pid(std::process::id());
484
1
    let mut written = 0usize;
485
2
    while written < SERIALIZED_PID_LENGTH {
486
1
        named_pipe_client.writable().await.unwrap_or_else(|err| 
{0
487
0
            panic!("Named pipe client is not writable for PID handshake: {err}")
488
        });
489
1
        match named_pipe_client.try_write(&pid_bytes[written..]) {
490
            Ok(0) => {
491
0
                panic!("Named pipe closed before PID handshake could complete");
492
            }
493
1
            Ok(n) => {
494
1
                written += n;
495
1
            }
496
0
            Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
497
0
                continue;
498
            }
499
0
            Err(e) => {
500
0
                panic!("Failed to send PID handshake to daemon: {e}");
501
            }
502
        }
503
    }
504
1
    return;
505
1
}
506
507
/// The main run loop of the client.
508
///
509
/// Connects to the named pipe opened by the daemon, reads all input records from it
510
/// and replays them to the console input buffer of the given child process.
511
/// Handles the `Alt + Shift + C` key combination used to close the console window
512
/// after the child process encountered an unexpected error.
513
///
514
/// # Arguments
515
///
516
/// * `api`         - The Windows API implementation to use.
517
/// * `child`       - Handle to the running SSH process.
518
/// * `state_sender`    - Watch sender used by [`read_write_loop`] to broadcast the
519
///                   client's authoritative [`ClientState`] to subscribers
520
///                   such as the visuals task in [`main`].
521
/// * `highlight_sender`- Watch sender used by [`read_write_loop`] to broadcast the
522
///                   client's current highlight flag.
523
0
async fn run(
524
0
    api: &dyn WindowsApi,
525
0
    child: &mut Child,
526
0
    state_sender: &watch::Sender<ClientState>,
527
0
    highlight_sender: &watch::Sender<bool>,
528
0
) {
529
    // Many clients trying to open the pipe at the same time can cause
530
    // a file not found error, so keep trying until we managed to open it
531
0
    let named_pipe_client: NamedPipeClient = loop {
532
0
        match ClientOptions::new().open(PIPE_NAME) {
533
0
            Ok(named_pipe_client) => {
534
0
                break named_pipe_client;
535
            }
536
            Err(_) => {
537
0
                continue;
538
            }
539
        }
540
    };
541
    // Identify ourselves to the daemon's pipe server by sending our PID.
542
    // The daemon uses this to correlate this pipe connection to the corresponding
543
    // client in its internal bookkeeping.
544
0
    send_pid_handshake(&named_pipe_client).await;
545
0
    let mut child_error = false;
546
0
    let mut internal_buffer: Vec<u8> = Vec::new();
547
    loop {
548
0
        named_pipe_client
549
0
            .ready(Interest::READABLE)
550
0
            .await
551
0
            .unwrap_or_else(|err| {
552
0
                error!("{}", err);
553
0
                panic!("Named client pipe is not ready to be read",)
554
            });
555
556
0
        match read_write_loop(
557
0
            api,
558
0
            &named_pipe_client,
559
0
            &mut internal_buffer,
560
0
            state_sender,
561
0
            highlight_sender,
562
        )
563
0
        .await
564
        {
565
            ReadWriteResult::Success {
566
0
                remainder,
567
0
                key_event_records,
568
            } => {
569
0
                internal_buffer = remainder;
570
0
                if child_error {
571
0
                    for key_event in key_event_records.into_iter() {
572
0
                        if is_alt_shift_c_combination(&key_event) {
573
0
                            return;
574
0
                        }
575
                    }
576
0
                }
577
            }
578
            ReadWriteResult::WouldBlock | ReadWriteResult::Err => {
579
                // Sleep some time to avoid hogging 100% CPU usage.
580
0
                tokio::time::sleep(Duration::from_nanos(5)).await;
581
            }
582
            ReadWriteResult::Disconnect => {
583
0
                warn!("Encountered disconnect when trying to read from named pipe");
584
0
                break;
585
            }
586
        }
587
0
        match child.try_wait() {
588
0
            Ok(Some(exit_status)) => match exit_status.code().unwrap() {
589
                0 | 1 | 130 => {
590
                    // 0 -> last command successful
591
                    // 1 -> last command unsuccessful
592
                    // 130 -> last command cancelled (Ctrl + C)
593
0
                    info!(
594
                        "Application terminated, last exit code: {}",
595
0
                        exit_status.code().unwrap()
596
                    );
597
0
                    break;
598
                }
599
                _ => {
600
0
                    if !child_error {
601
0
                        println!("Failed to establish SSH connection: {exit_status}");
602
0
                        println!("Shift-Alt-C to exit");
603
0
                        child_error = true;
604
0
                    }
605
                }
606
            },
607
0
            Ok(None) => (
608
0
                // child is still running
609
0
            ),
610
0
            Err(e) => panic!("{}", e),
611
        }
612
    }
613
0
}
614
615
/// Snapshot the current console color.
616
///
617
/// # Arguments
618
///
619
/// * `api` - The Windows API implementation to use.
620
///
621
/// # Returns
622
///
623
/// `Some(original)` on success, `None` if the buffer info could not be read.
624
0
fn capture_original_console_color(api: &dyn WindowsApi) -> Option<CONSOLE_CHARACTER_ATTRIBUTES> {
625
0
    match api.get_console_screen_buffer_info() {
626
0
        Ok(info) => return Some(info.wAttributes),
627
0
        Err(err) => {
628
0
            warn!(
629
                "Failed to capture original console color; state visuals will be skipped: {}",
630
                err
631
            );
632
0
            return None;
633
        }
634
    }
635
0
}
636
637
/// Splits `host` on its trailing `:port` suffix (if any) and parses
638
/// the port. An invalid `:port` is logged and treated as absent so
639
/// the CLI port can still apply.
640
///
641
/// # Arguments
642
///
643
/// * `host` - Raw host argument, optionally with `:port` suffix.
644
///
645
/// # Returns
646
///
647
/// `(host_without_port, inline_port)`.
648
0
fn split_host_and_inline_port(host: &str) -> (&str, Option<u16>) {
649
0
    let (bare_host, port_str) = host
650
0
        .rsplit_once(':')
651
0
        .map_or((host, None), |(h, p)| return (h, Some(p)));
652
0
    let inline_port = port_str.and_then(|p| {
653
0
        return p
654
0
            .parse::<u16>()
655
0
            .map_err(|e| {
656
0
                warn!("Invalid port '{}': {}. Using default SSH port.", p, e);
657
0
            })
658
0
            .ok();
659
0
    });
660
0
    return (bare_host, inline_port);
661
0
}
662
663
/// Builds the console window title shown to the user.
664
///
665
/// # Arguments
666
///
667
/// * `resolved_username` - Username after SSH config resolution.
668
/// * `host`              - Bare hostname.
669
/// * `port`              - Effective port (inline or CLI), if any.
670
///
671
/// # Returns
672
///
673
/// The console title string in `csshw - user@host[:port]` form.
674
0
fn build_console_title(resolved_username: &str, host: &str, port: Option<u16>) -> String {
675
0
    let title_host = if let Some(port) = port {
676
0
        format!("{host}:{port}")
677
    } else {
678
0
        host.to_string()
679
    };
680
0
    return format!("{PKG_NAME} - {resolved_username}@{title_host}");
681
0
}
682
683
/// Keeps the console window title pinned to `console_title`, since
684
/// the SSH child can overwrite it on connect.
685
///
686
/// # Arguments
687
///
688
/// * `api`           - The Windows API implementation to use.
689
/// * `console_title` - The title to (re)apply.
690
0
async fn run_title_loop(api: &dyn WindowsApi, console_title: String) {
691
    loop {
692
0
        if console_title != get_console_title(api) {
693
0
            api.set_console_title(console_title.as_str())
694
0
                .unwrap_or_else(|err| {
695
0
                    error!("Failed to set console title: {}", err);
696
0
                });
697
0
        }
698
0
        tokio::time::sleep(Duration::from_millis(5)).await;
699
    }
700
}
701
702
/// Drives the per-client console color: tracks `state_receiver` and
703
/// `highlight_receiver`, paints the steady-state combination, and flashes
704
/// the underlying state color for [`HIGHLIGHT_FLASH_DURATION`].
705
///
706
/// # Arguments
707
///
708
/// * `api`                       - The Windows API implementation to use.
709
/// * `state_receiver`                  - Receiver for daemon-driven state changes.
710
/// * `highlight_receiver`              - Receiver for submenu highlight transitions.
711
/// * `original_console_color`    - Pristine attributes; `None`
712
///                                 degrades all painting to a no-op.
713
/// * `disabled_console_color`    - Color for [`ClientState::Disabled`].
714
/// * `highlighted_console_color` - Color while highlighted; overrides
715
///                                 the disabled color.
716
1
async fn run_visuals_loop(
717
1
    api: &dyn WindowsApi,
718
1
    mut state_receiver: watch::Receiver<ClientState>,
719
1
    mut highlight_receiver: watch::Receiver<bool>,
720
1
    original_console_color: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
721
1
    disabled_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
722
1
    highlighted_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
723
1
) {
724
1
    let palette = ConsolePalette {
725
1
        original: original_console_color,
726
1
        disabled: disabled_console_color,
727
1
        highlighted: highlighted_console_color,
728
1
    };
729
1
    let mut prev_state = *state_receiver.borrow_and_update();
730
1
    let mut prev_highlight = *highlight_receiver.borrow_and_update();
731
1
    let mut last_painted: Option<CONSOLE_CHARACTER_ATTRIBUTES> = None;
732
1
    let mut flash_until: Option<tokio::time::Instant> = None;
733
734
1
    paint_steady(api, prev_state, prev_highlight, &palette, &mut last_painted);
735
736
    loop {
737
        // Independent watch channels: `state_receiver` and `highlight_receiver` may be observed out of send-order, so the flash branch can fire (or not) on stale `prev_highlight`.
738
3
        tokio::select! {
739
3
            
state_changed1
= state_receiver.changed() => {
740
1
                if state_changed.is_err() {
741
0
                    return;
742
1
                }
743
1
                prev_state = *state_receiver.borrow_and_update();
744
1
                if prev_highlight {
745
1
                    flash_until = Some(start_flash(api, prev_state, &palette, &mut last_painted));
746
1
                } else {
747
0
                    paint_steady(api, prev_state, prev_highlight, &palette, &mut last_painted);
748
0
                    flash_until = None;
749
0
                }
750
            }
751
3
            
highlight_changed1
= highlight_receiver.changed() => {
752
1
                if highlight_changed.is_err() {
753
1
                    return;
754
0
                }
755
0
                let next_highlight = *highlight_receiver.borrow_and_update();
756
0
                if next_highlight == prev_highlight {
757
0
                    continue;
758
0
                }
759
0
                prev_highlight = next_highlight;
760
0
                flash_until = None;
761
0
                paint_steady(api, prev_state, prev_highlight, &palette, &mut last_painted);
762
            }
763
3
            _ = async {
764
3
                match flash_until {
765
1
                    Some(deadline) => tokio::time::sleep_until(deadline).await,
766
2
                    None => std::future::pending::<()>().await,
767
                }
768
1
            } => {
769
1
                flash_until = None;
770
1
                paint_steady(api, prev_state, prev_highlight, &palette, &mut last_painted);
771
1
            }
772
        }
773
    }
774
1
}
775
776
/// The entrypoint for the `client` subcommand with API dependency injection.
777
///
778
/// Spawns a tokio background thread to ensure the console window title is not replaced
779
/// by the name of the child process once its launched.
780
/// Starts the SSH process as child process.
781
/// Executes the main run loop.
782
///
783
/// # Arguments
784
///
785
/// * `api`         - The Windows API implementation to use.
786
/// * `host`        - The name of the host to connect to, optionally with `:port` suffix.
787
/// * `username`    - The username to be used.
788
///                   Will try to resolve the correct username from the ssh config
789
///                   if none is given.
790
/// * `cli_port`    - Optional port from CLI option. Inline port takes precedence.
791
/// * `config`      - A reference to the `ClientConfig`.
792
0
pub async fn main(
793
0
    api: &dyn WindowsApi,
794
0
    host: String,
795
0
    username: Option<String>,
796
0
    cli_port: Option<u16>,
797
0
    config: &ClientConfig,
798
0
) {
799
0
    let original_console_color = capture_original_console_color(api);
800
801
0
    let (state_sender, state_receiver) = watch::channel(ClientState::Active);
802
0
    let (highlight_sender, highlight_receiver) = watch::channel(false);
803
804
0
    let (host, inline_port) = split_host_and_inline_port(&host);
805
0
    let port = inline_port.or(cli_port);
806
807
0
    let resolved_username = resolve_username(username, host, config);
808
0
    let console_title = build_console_title(&resolved_username, host, port);
809
810
0
    let title_task = run_title_loop(api, console_title);
811
0
    let child_task = async {
812
0
        let mut child = launch_ssh_process(&resolved_username, host, port, config).await;
813
0
        run(api, &mut child, &state_sender, &highlight_sender).await;
814
0
        return child;
815
0
    };
816
0
    let visuals_task = run_visuals_loop(
817
0
        api,
818
0
        state_receiver,
819
0
        highlight_receiver,
820
0
        original_console_color,
821
0
        CONSOLE_CHARACTER_ATTRIBUTES(config.disabled_console_color),
822
0
        CONSOLE_CHARACTER_ATTRIBUTES(config.highlighted_console_color),
823
    );
824
825
    // The title and visuals tasks are infinite by construction; if either
826
    // ever completes, that is a logic bug, not a shutdown path.
827
0
    let child = tokio::select! {
828
0
        child = child_task => child,
829
0
        _ = title_task => {
830
0
            panic!("Title task should never complete");
831
        }
832
0
        _ = visuals_task => {
833
0
            panic!("Visuals task should never complete");
834
        }
835
    };
836
837
0
    api.generate_console_ctrl_event(0, 0).unwrap_or_else(|err| {
838
0
        error!("{}", err);
839
0
        panic!("Failed to send `ctrl + c` to remaining client windows",)
840
    });
841
0
    drop(child);
842
0
}
843
844
#[cfg(test)]
845
#[path = "../tests/client/test_mod.rs"]
846
mod test_mod;